iT邦幫忙

2024 iThome 鐵人賽

DAY 20
0
JavaScript

Vue.js學習中的細節陷阱:30天自我學習指南系列 第 20

Day 20: Vue - 依賴注入模式(provide/inject) 和 pinia 使用介紹

  • 分享至 

  • xImage
  •  

在前面的章節當中我們應該對元件props/emits有一定掌握,不過在Vue中元件間的關係可能不只有上下層父子關係,在元件粒度切分比較細時,會有事件或參數(props)傳遞較多層的問題,就會形成props drilling現象。

Vue提供了依賴注入(provide and inject)的API,讓我們可以跨越某些中間層組件,去達到資料傳遞功能。同時,也稍微聊聊認識一下pinia全域資料狀態管理的使用。

今日學習目標

  1. 理解provide inject 和正確使用模式
  2. 製作provider無渲染元件,抽離provide inject邏輯
  3. 認識全局狀態管理pinia的使用

props drilling

props drilling 是指當資料需要從父元件傳遞給深層次的子元件時,必須經過多層中間元件,這些中間元件僅僅是為了傳遞這些 props 而存在,並不真正使用這些資料。

當元件必須處理與自身功能無關的 props 以支持深層次的資料傳遞時,這些元件的重用性會降低。這是因為它們變得依賴於與其不相關的資料或邏輯。資料傳遞間也會增加了在某一層級出錯的風險。

https://ithelp.ithome.com.tw/upload/images/20241003/201452510wjG7Juy6m.png
(圖片出處)


Provide Inject 模式

Vue 的 provideinject 是一個用於在同一元件樹中共享資料的API工具,通常用來讓祖先元件將資料傳遞給後代子孫元件,而不用透過 props 層層傳遞。這種方式可以有效避免 props drilling 問題,讓資料更容易在不同層級的元件之間共享。

不過單純這麼直接使用對資料流還不是很牢靠,因為傳遞進入子孫組件的響應式資料,是有機會被更動的,造成難以察覺的錯誤。

所以為了讓 provide 提供的資料不能讓子孫元件隨意直接修改,確保資料的安全性和一致性,可以採取以下措施:

  1. 使用 readonly 將provider提供的數據變成唯讀,會在開發模式下提出警告。
  2. 提供一個update數據的函式,子孫組件需要更新provider數據來使用,有點像是提醒開發者有意識地知道說你在調動 provider 裡面的資料。
import {provide, inject} from 'vue'
// 父元件
const sharedData = ref({ value: 'some data' })
function updateState(newValue) {
  state.value = newValue;
}

provide('sharedData', sharedData));
provide('updateState', updateState);


// 深層次的子元件
const sharedData = inject('sharedData','')  // inject 後面可設計預設值,避免注入名錯誤
const updateState = inject('updateState',()=>{})


如果傳遞資料的是物件(object),最好進行完整的複製(深拷貝)。上次有提到因為readonly本身不會對這些資料變更提出警告,它仍然觸發Vue攔截到直接修改整個物件的引用(reference),進而使資料更新到畫面上。

或者像上面一樣定義個 update funciton,對於元件有需要對原始資料進行更新,才以inject注入提供更新資料的功能。

// 一般型別資料provider
import { provide, readonly, ref } from 'vue';

const state = ref('This is a string');

provide('sharedState', readonly(state));

// 子孫組件使用
const sharedState = inject('sharedState');

console.log(sharedState.value); // "This is a string"
sharedState.value = 'New value'; // 將在開發模式下觸發警告,但數據仍會更新
// 物件型別provider
import { ref, readonly, provide } from 'vue';

const state = ref({
  nested: {
    value: 'This is a nested value'
  }
});

// 創建深拷貝
const deepCopyState = JSON.parse(JSON.stringify(state));

provide('sharedState', readonly(deepCopyState));

製作provider無渲染元件,將provider邏輯從頂層元件抽離

如果我們有很多個 provider,都定義在頂層組件上的話,會使得該組件中的響應式定義資料變數顯得比較肥大,我們其實可以把它抽離出來,製作成一個 provider 資料元件,是一種只負責傳遞資料的無渲染組件(renderless component)。上次介紹 slot props文章,是將互動按鈕的邏輯製作在無渲染元件中,其實它也可以製作變成資料傳遞元件(data provider component)

我們來實際做一個專門提供 provider資料 的無渲染元件:

  • 創建 provider composable Hook

運用昨天我們已經提到的組合式函式概念,先寫一個 useProvider ,將要共享的邏輯包裝起來。如果有很多個不同邏輯的 provider,也可以分開檔案再引入集中邏輯,或直接定義另外的組合式函式,這樣可以使代碼結構更加清晰、模組化,並且便於維護和擴展。

// useProvider.js
import { ref, provide, readonly } from 'vue';

export function useProvider() {
  const state = ref({
    value: 'Hello World',
    count: 0,
  });

  const increment = () => {
    state.count++;
  };

  // 補上readonly 防止資料意外被竄改
  provide('sharedState', readonly(state));
  provide('increment', increment);
}

export function useProvider2() {
  const state2 = ref({
    value: 'Provider 2 state'
  });

  provide('provider2State', readonly(state2));
}
  • 用slot製作一個只提供provider資料的無渲染組件(Provider Renderless Component)

provider component 中引入剛才寫好的 useProvider,綁入元件setup中初始化響應式資料。

<template>
  <div>
    <slot></slot>
  </div>
</template>

<script setup>
import { useProvider,useProvider2 } from './useProvider';

// 調用 useProvider 綁入共享資料
useProvider();
useProvider2()
</script>

  • 將要共享資料的範圍綁在頂層父元件外圍

使用無渲染的 Provider 組件來包裹頂層組件(root component),這樣底下的子孫組件都能共享 Provider 提供的數據或方法,使用起來跟React useContext 感覺滿類似的:

<template>
  <ProviderComponent>
    <MyConsumerComponent />
    <AnotherConsumerComponent />
  </ProviderComponent>
</template>

<script setup>
import ProviderComponent from './ProviderComponent.vue';
import MyConsumerComponent from './MyConsumerComponent.vue';
import AnotherConsumerComponent from './AnotherConsumerComponent.vue';
</script>

認識全局狀態管理 pinia 的使用

認識完 provide/inject 是 Vue 提供的一種依賴注入機制,能夠在父子組件之間傳遞資料。但是隨著應用規模擴大,而且這些元件彼此間式沒有上下游關係,但又需要共享狀態時,provide/inject的靈活性可能會受到限制。這時可以用全局狀態管理工具如 PiniaVuex 來統一管理應用的狀態,會比較合適一些。

Pinia - 以 Store 作為資料管理單位的概念

PiniaVuex 的主要區別之一是 Pinia 不再強調將狀態管理切割成「模塊」的概念,而是通過「單一」或「多個」 store 來管理不同的狀態區域。Pinia 的設計能讓每個 store 都成為一個獨立的單位,並且避免了 Vuex 中的 module定義和註冊繁瑣的過程和模組命名上的衝突。


(圖片出處)

Store - 資料狀態(state)、讀取數據(getter)和更新動作(actions)

在使用 PiniaStore時,我們可以透過 Vue Composition API 中的 refcomputed
function 來輕鬆定義 Store 的資料狀態讀取數據行為

以Composition API 來設計 Store 中,可以依循這樣規則來分類:

  • ref() 就是 state 屬性
  • computed() 就是 getters 屬性
  • function() 就是 actions 屬性
export const useCounterStore = defineStore('counter', () => 
  const count = ref(0) // 原始資料
  const doubleCount = computed(() => count.value * 2) // 只能讀取
  
  // 更新數據動作
  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})

// main.js Vue實例掛載註冊使用Pinia

import { createPinia } from 'pinia'
// retrieve the rootState server side
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)


注意元件中使用時解構會喪失響應性

Note that store is an object wrapped with reactive, meaning there is no need to write .value after getters but, like props in setup, we cannot destructure it:

Pinia 中的 Store 是通過 reactive 將其狀態包裝成響應式物件,因此跟之前提到過響應式資料reactive一樣,解構賦值會喪失響應性,如果需要對 Pinia Store 中取出來的資料進行操作,又能達到響應式更新的話需要使用storeToRef轉成ref物件

<script setup>
import { storeToRefs } from 'pinia'
const store = useCounterStore()

// name 和 doubleCount 會轉成ref物件並和store裡面定義的資料連動

const { name, doubleCount } = storeToRefs(store)

name.value = 'abc' // 這裡會更動到store資料

// 作為 action 的 increment 函式則式可以直接使用
const { increment } = store

</script>

Pinia 原始數據更動的探討

const { name, doubleCount } = storeToRefs(store)
name.value = 'abc' // 這裡會更動到store資料

但我們可以發現是上面直接引用 Store 數據很方便有缺點是,引入元件利用storeToRef轉成ref物件 的資料都能直接隨意更動的話,當共用狀態元件一多時,可能會有蠻難追蹤的問題。

目前官方文件是說明不建議使用私有屬性,建議是Store裡面定義的所有資料和方法能夠一併忠實返回,能夠讓 devtool 方便調適,SSR渲染時也比較不會出現問題。或者使用 computed 官方推薦動作等去作讀取動作:

https://ithelp.ithome.com.tw/upload/images/20241003/20145251XWFThqGsoC.png

不過看到有人在社群討論區裡面寫的案例:

跟上面提到一樣使用 readonly ,對不想被隨意更動的資料進行封裝,在開發模式下元件賦值時會出現警示,當然我們可以另外定義 updateUser 讓使用者去更新資料或者有需要的元件才引入使用,避免資料過度暴露各元件上而有太多更動風險存在。

import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
defineStore('user', () => {
  const user = ref(null)
  function updateUser(val) {
     user.value = val
  }
  return { user: readonly(user), updateUser } // readonly()
})

全局狀態管理 Pinia vs Provid-Inject模式

那麼到底兩者間什麼時候該使用它們?

分享自己簡單應用上的選擇思路,實務上每個團隊習慣可能不同,可以根據規範自己選用。

  • 小型應用或單個功能模塊使用Provider(彼此間有明顯父子元件樹關係)

像是一個頂層的組間表單組件,這個表單包含多個子元件(如輸入框、選擇框等),這些子元件需要共享表單的狀態,例如表單的有效性、錯誤訊息等。

表單的狀態因為不太需要在整個應用程式中共享,只需要在這個表單元件及其子元件中傳遞使用,這種情況可以用簡單的Provide-Inject模式處理。

  • 大型應用全局狀態使用 Pinia(但組件設計上沒有明顯上下游關係、跨頁面傳遞使用)

例如一個大型電子商務應用,這個應用有多個組件(如用戶管理、購物車、產品展示等),它們可能散落在不同頁面上,但每個組件可能需要共享用戶的身份驗證狀態,購物車內容、產品信息等,才能執行後續的功能。


總結

  1. provide/inject 用於在元件樹中的祖先組件和後代組件之間共享數據或方法,無需通過中間層層傳遞 props 適合用於簡單的數據共享場景,特別是在有明確父子關係的小型應用或功能

  2. 使用上雖然也能夠將 provide/inject 掛在 App.vue 全局應用頂層元件下使用,但在相對大型應用或複雜狀態管理中,此時可能需要引入 Pinia 這類全局的狀態管理工具


學習資源:

  1. https://penueling.com/線上學習/vue3-的資料狀態管理
  2. https://www.youtube.com/watch?app=desktop&v=dOxjzgZpTfk
  3. https://upmostly.com/vue-js/how-to-use-provide-and-inject-in-vue-3
  4. https://vueschool.io/articles/vuejs-tutorials/tightly-coupled-components-vue-components-with-provide-inject/
  5. https://medium.com/js-dojo/pinia-the-new-and-better-state-management-system-for-vue-8b6f8a64f2e2
  6. https://penueling.com/%e7%b7%9a%e4%b8%8a%e5%ad%b8%e7%bf%92/vue3-%e7%9a%84%e8%b3%87%e6%96%99%e7%8b%80%e6%85%8b%e7%ae%a1%e7%90%86%ef%bc%8cprovide-inject%e3%80%81vuex/

上一篇
Day 19: Vue - 組合式邏輯概念 (Vue composable concept)
下一篇
Day 21: SOLID - 單一職責原則(SRP) 和 Vue的元件開發
系列文
Vue.js學習中的細節陷阱:30天自我學習指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言